/* * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.apache.karaf.cave.server.maven; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.security.Principal; import java.util.Enumeration; import java.util.Properties; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.jar.JarEntry; import java.util.jar.JarFile; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.security.auth.Subject; import javax.security.auth.callback.Callback; import javax.security.auth.callback.CallbackHandler; import javax.security.auth.callback.NameCallback; import javax.security.auth.callback.PasswordCallback; import javax.security.auth.callback.UnsupportedCallbackException; import javax.security.auth.login.AccountException; import javax.security.auth.login.FailedLoginException; import javax.security.auth.login.LoginContext; import javax.security.auth.login.LoginException; import javax.servlet.AsyncContext; import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.xml.bind.DatatypeConverter; import org.apache.karaf.util.StreamUtils; import org.ops4j.pax.url.mvn.MavenResolver; import org.osgi.framework.Bundle; import org.osgi.framework.FrameworkUtil; import org.osgi.service.http.HttpContext; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class CaveMavenServlet extends HttpServlet { public static Logger LOGGER = LoggerFactory.getLogger(CaveMavenServlet.class); public static final Pattern REPOSITORY_ID_REGEX = Pattern.compile("[^ ]*(@id=([^@ ]+))+[^ ]*"); private static final String SNAPSHOT_TIMESTAMP_REGEX = "^([0-9]{8}.[0-9]{6}-[0-9]+).*"; private static final Pattern SNAPSHOT_TIMESTAMP_PATTERN = Pattern.compile(SNAPSHOT_TIMESTAMP_REGEX); //The pattern below matches a path to the following: //1: groupId //2: artifactId //3: version //4: artifact filename public static final Pattern ARTIFACT_REQUEST_URL_REGEX = Pattern.compile("([^ ]+)/([^/ ]+)/([^/ ]+)/([^/ ]+)"); //The pattern bellow matches the path to the following: //1: groupId //2: artifactId //3: version //4: maven-metadata xml filename //7: repository id. //9: type public static final Pattern ARTIFACT_METADATA_URL_REGEX = Pattern.compile("([^ ]+)/([^/ ]+)/([^/ ]+)/(maven-metadata([-]([^ .]+))?.xml)([.]([^ ]+))?"); private static final String HEADER_WWW_AUTHENTICATE = "WWW-Authenticate"; private static final String HEADER_AUTHORIZATION = "Authorization"; private static final String AUTHENTICATION_SCHEME_BASIC = "Basic"; protected static final String LOCATION_HEADER = "X-Location"; private final ConcurrentMap<String, ArtifactDownloadFuture> requestMap = new ConcurrentHashMap<>(); private final int threadMaximumPoolSize; private final String realm; private final String downloadRole; private final String uploadRole; private ThreadPoolExecutor executorService; protected File tmpFolder = new File(System.getProperty("karaf.data") + File.separator + "maven" + File.separator + "proxy" + File.separator + "tmp"); final MavenResolver resolver; public CaveMavenServlet(MavenResolver resolver, int threadMaximumPoolSize, String realm, String downloadRole, String uploadRole) { this.resolver = resolver; this.threadMaximumPoolSize = threadMaximumPoolSize; this.realm = realm; this.downloadRole = downloadRole; this.uploadRole = uploadRole; } // // Lifecycle // @Override public void init() throws ServletException { if (!tmpFolder.exists() && !tmpFolder.mkdirs()) { throw new ServletException("Failed to create temporary artifact folder"); } // Create a thread pool with the given maxmimum number of threads // All threads will time out after 60 seconds int nbThreads = threadMaximumPoolSize > 0 ? threadMaximumPoolSize : 8; executorService = new ThreadPoolExecutor(0, nbThreads, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>(), new ThreadFactory("MavenDownloadProxyServlet")); } @Override public void destroy() { if (executorService != null) { executorService.shutdown(); try { executorService.awaitTermination(5, TimeUnit.MINUTES); } catch (InterruptedException e) { executorService.shutdownNow(); } } } // // Security // protected boolean authorize(HttpServletRequest request, HttpServletResponse response, String role) throws IOException { if (role == null) { return true; } // Return immediately if the header is missing String authHeader = request.getHeader(HEADER_AUTHORIZATION); if (authHeader != null && authHeader.length() > 0) { // Get the authType (Basic, Digest) and authInfo (user/password) // from the header authHeader = authHeader.trim(); int blank = authHeader.indexOf(' '); if (blank > 0) { String authType = authHeader.substring(0, blank); String authInfo = authHeader.substring(blank).trim(); // Check whether authorization type matches if (authType.equalsIgnoreCase(AUTHENTICATION_SCHEME_BASIC)) { try { String srcString = base64Decode(authInfo); int i = srcString.indexOf(':'); String username = srcString.substring(0, i); String password = srcString.substring(i + 1); // authenticate Subject subject = doAuthenticate(username, password, role); if (subject != null) { // as per the spec, set attributes request.setAttribute(HttpContext.AUTHENTICATION_TYPE, HttpServletRequest.BASIC_AUTH); request.setAttribute(HttpContext.REMOTE_USER, username); // succeed return true; } } catch (Exception e) { // Ignore } } } } // request authentication try { response.setHeader(HEADER_WWW_AUTHENTICATE, AUTHENTICATION_SCHEME_BASIC + " realm=\"" + this.realm + "\""); // must response with status and flush as Jetty may report org.eclipse.jetty.server.Response Committed before 401 null response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentLength(0); response.flushBuffer(); } catch (IOException ioe) { // failed sending the response ... cannot do anything about it } // inform HttpService that authentication failed return false; } private static String base64Decode(String srcString) { byte[] transformed = DatatypeConverter.parseBase64Binary(srcString); return new String(transformed, StandardCharsets.ISO_8859_1); } public Subject doAuthenticate(final String username, final String password, final String role) { try { Subject subject = new Subject(); LoginContext loginContext = new LoginContext(realm, subject, new CallbackHandler() { public void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException { for (Callback callback : callbacks) { if (callback instanceof NameCallback) { ((NameCallback) callback).setName(username); } else if (callback instanceof PasswordCallback) { ((PasswordCallback) callback).setPassword(password.toCharArray()); } else { throw new UnsupportedCallbackException(callback); } } } }); loginContext.login(); if (role != null && role.length() > 0) { String clazz = "org.apache.karaf.jaas.boot.principal.RolePrincipal"; String name = role; int idx = role.indexOf(':'); if (idx > 0) { clazz = role.substring(0, idx); name = role.substring(idx + 1); } boolean found = false; for (Principal p : subject.getPrincipals()) { if (p.getClass().getName().equals(clazz) && p.getName().equals(name)) { found = true; break; } } if (!found) { throw new FailedLoginException("User does not have the required role " + role); } } return subject; } catch (AccountException e) { LOGGER.warn("Account failure", e); return null; } catch (LoginException e) { LOGGER.debug("Login failed", e); return null; } } // // Download // @Override protected void doGet(final HttpServletRequest req, final HttpServletResponse resp) throws ServletException, IOException { if (!authorize(req, resp, downloadRole)) { return; } String tpath = req.getPathInfo(); if (tpath == null) { resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); return; } if (tpath.startsWith("/")) { tpath = tpath.substring(1); } final String path = tpath; final AsyncContext asyncContext = req.startAsync(); asyncContext.setTimeout(TimeUnit.MINUTES.toMillis(5)); final ArtifactDownloadFuture future = new ArtifactDownloadFuture(path); ArtifactDownloadFuture masterFuture = requestMap.putIfAbsent(path, future); if (masterFuture == null) { masterFuture = future; masterFuture.lock(); executorService.execute(new Runnable() { @Override public void run() { try { File file = download(path); future.setValue(file); } catch (Throwable t) { future.setValue(t); } } }); } else { masterFuture.lock(); } masterFuture.addListener(new FutureListener<ArtifactDownloadFuture>() { @Override public void operationComplete(ArtifactDownloadFuture future) { Object value = future.getValue(); if (value instanceof Throwable) { LOGGER.warn("Error while downloading artifact: {}", ((Throwable) value).getMessage(), value); resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); } else if (value instanceof File) { File artifactFile = (File) value; try (InputStream is = new FileInputStream(artifactFile)) { LOGGER.info("Writing response for file : {}", path); resp.setStatus(HttpServletResponse.SC_OK); resp.setContentType("application/octet-stream"); resp.setDateHeader("Date", System.currentTimeMillis()); resp.setHeader("Connection", "close"); resp.setContentLength(is.available()); Bundle bundle = FrameworkUtil.getBundle(getClass()); if (bundle != null) { resp.setHeader("Server", bundle.getSymbolicName() + "/" + bundle.getVersion()); } else { resp.setHeader("Server", "Karaf Maven Proxy"); } StreamUtils.copy(is, resp.getOutputStream()); } catch (Exception e) { LOGGER.warn("Error while sending artifact: {}", e.getMessage(), e); resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); } } else { resp.setStatus(HttpServletResponse.SC_NOT_FOUND); } future.release(); try { asyncContext.complete(); } catch (IllegalStateException e) { // Ignore, the response must have already been sent with an error } } }); } public File download(String path) throws InvalidMavenArtifactRequest { if (path == null) { throw new InvalidMavenArtifactRequest(); } Matcher artifactMatcher = ARTIFACT_REQUEST_URL_REGEX.matcher(path); Matcher metadataMatcher = ARTIFACT_METADATA_URL_REGEX.matcher(path); if (metadataMatcher.matches()) { LOGGER.info("Received request for maven metadata : {}", path); try { MavenCoord coord = convertMetadataPathToCoord(path); return resolver.resolveMetadata(coord.groupId, coord.artifactId, coord.type, coord.version); } catch (Exception e) { LOGGER.warn(String.format("Could not find metadata : %s due to %s", path, e.getMessage()), e); return null; } } else if (artifactMatcher.matches()) { LOGGER.info("Received request for maven artifact : {}", path); try { MavenCoord artifact = convertArtifactPathToCoord(path); Path download = resolver.resolve(artifact.groupId, artifact.artifactId, artifact.classifier, artifact.type, artifact.version).toPath(); Path tmpFile = Files.createTempFile("mvn-", ".tmp"); Files.copy(download, tmpFile, StandardCopyOption.REPLACE_EXISTING); return tmpFile.toFile(); } catch (Exception e) { LOGGER.warn(String.format("Could not find artifact : %s due to %s", path, e.getMessage()), e); return null; } } return null; } private class ArtifactDownloadFuture extends DefaultFuture<ArtifactDownloadFuture> { private final AtomicInteger participants = new AtomicInteger(); private final String path; private ArtifactDownloadFuture(String path) { this.path = path; } public void lock() { participants.incrementAndGet(); } public void release() { if (participants.decrementAndGet() == 0) { requestMap.remove(path); Object v = getValue(); if (v instanceof File) { ((File) v).delete(); } } } } // // Upload // @Override protected void doPut(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { if (!authorize(request, response, uploadRole)) { return; } try { String path = request.getPathInfo(); //Make sure path is valid if (path != null) { if (path.startsWith("/")) { path = path.substring(1); } } if (path == null || path.isEmpty()) { response.setStatus(HttpServletResponse.SC_BAD_REQUEST); return; } boolean result; // handle move String location = request.getHeader(LOCATION_HEADER); if (location != null) { result = upload(new File(location), path, response); } else { Path dir = tmpFolder.toPath().resolve(UUID.randomUUID().toString()); Path temp = dir.resolve(Paths.get(path).getFileName()); Files.createDirectories(dir); try (OutputStream os = Files.newOutputStream(temp)) { StreamUtils.copy(request.getInputStream(), os); } result = upload(temp.toFile(), path, response); } response.setStatus(result ? HttpServletResponse.SC_ACCEPTED : HttpServletResponse.SC_NOT_ACCEPTABLE); } catch (InvalidMavenArtifactRequest ex) { // must response with status and flush as Jetty may report org.eclipse.jetty.server.Response Committed before 401 null response.setStatus(HttpServletResponse.SC_BAD_REQUEST); response.setContentLength(0); response.flushBuffer(); } catch (Exception ex) { // must response with status and flush as Jetty may report org.eclipse.jetty.server.Response Committed before 401 null response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); response.setContentLength(0); response.flushBuffer(); } } protected boolean upload(File input, String path, HttpServletResponse response) throws InvalidMavenArtifactRequest, NoSuchFileException { if (!input.isFile()) { throw new NoSuchFileException(input.toString()); } if (path == null) { throw new InvalidMavenArtifactRequest(); } // root path, try reading mvn coords if (path.indexOf('/') < 0) { try { String mvnCoordsPath = readMvnCoordsPath(input); if (mvnCoordsPath != null) { return install(input, mvnCoordsPath); } else { response.addHeader(LOCATION_HEADER, input.toString()); // we need manual mvn coords input return true; } } catch (Exception e) { LOGGER.warn(String.format("Failed to deploy artifact : %s due to %s", path, e.getMessage()), e); return false; } } return install(input, path); } private boolean install(File file, String path) { Matcher artifactMatcher = ARTIFACT_REQUEST_URL_REGEX.matcher(path); Matcher metadataMatcher = ARTIFACT_METADATA_URL_REGEX.matcher(path); if (metadataMatcher.matches()) { LOGGER.info("Received upload request for maven metadata : {}", path); try { MavenCoord coord = convertMetadataPathToCoord(path); resolver.uploadMetadata(coord.groupId, coord.artifactId, coord.type, coord.version, file); LOGGER.info("Maven metadata installed: {}", coord.toString()); return true; } catch (Exception e) { LOGGER.warn(String.format("Failed to upload metadata: %s due to %s", path, e.getMessage()), e); return false; } //If no matching metadata found return nothing } else if (artifactMatcher.matches()) { LOGGER.info("Received upload request for maven artifact : {}", path); try { MavenCoord coord = convertArtifactPathToCoord(path); resolver.upload(coord.groupId, coord.artifactId, coord.classifier, coord.type, coord.version, file); LOGGER.info("Artifact installed: {}", coord.toString()); return true; } catch (Exception e) { LOGGER.warn(String.format("Failed to upload artifact : %s due to %s", path, e.getMessage()), e); return false; } } return false; } protected static String readMvnCoordsPath(File file) throws Exception { try (JarFile jarFile = new JarFile(file)) { String previous = null; String match = null; Enumeration<JarEntry> entries = jarFile.entries(); while (entries.hasMoreElements()) { JarEntry entry = entries.nextElement(); String name = entry.getName(); if (name.startsWith("META-INF/maven/") && name.endsWith("pom.properties")) { if (previous != null) { throw new IllegalStateException(String.format("Duplicate pom.properties found: %s != %s", name, previous)); } previous = name; // check for dups Properties props = new Properties(); try (InputStream stream = jarFile.getInputStream(entry)) { props.load(stream); } String groupId = props.getProperty("groupId"); String artifactId = props.getProperty("artifactId"); String version = props.getProperty("version"); String type = getFileExtension(file); match = String.format("%s/%s/%s/%s-%s.%s", groupId, artifactId, version, artifactId, version, type != null ? type : "jar"); } } return match; } } private static String getFileExtension(File file) { String fileName = file.getName(); int idx = fileName.lastIndexOf('.'); if (idx > 1) { String answer = fileName.substring(idx + 1); if (answer.length() > 0) { return answer; } } return null; } /** * Converts the path of the request to maven coords. * * @param path The request path, following the format: {@code <groupId>/<artifactId>/<version>/<artifactId>-<version>-[<classifier>].extension} * @return A {@link MavenCoord} * @throws InvalidMavenArtifactRequest */ protected MavenCoord convertArtifactPathToCoord(String path) throws InvalidMavenArtifactRequest { if (path == null) { throw new InvalidMavenArtifactRequest("Cannot match request path to maven url, request path is empty."); } Matcher pathMatcher = ARTIFACT_REQUEST_URL_REGEX.matcher(path); if (pathMatcher.matches()) { String groupId = pathMatcher.group(1).replaceAll("/", "."); String artifactId = pathMatcher.group(2); String version = pathMatcher.group(3); String filename = pathMatcher.group(4); String extension; String classifier = ""; String filePerfix = artifactId + "-" + version; String stripedFileName; if (version.endsWith("SNAPSHOT")) { String baseVersion = version.replaceAll("-SNAPSHOT", ""); String timestampedFileName = filename.substring(artifactId.length() + baseVersion.length() + 2); //Check if snapshot is timestamped and override the version. @{link Artifact} will still treat it as a SNAPSHOT. //and also in case of artifact installation the proper filename will be used. Matcher ts = SNAPSHOT_TIMESTAMP_PATTERN.matcher(timestampedFileName); if (ts.matches()) { version = baseVersion + "-" + ts.group(1); filePerfix = artifactId + "-" + version; } stripedFileName = filename.replaceAll(SNAPSHOT_TIMESTAMP_REGEX, "SNAPSHOT"); stripedFileName = stripedFileName.substring(filePerfix.length()); } else { stripedFileName = filename.substring(filePerfix.length()); } if (stripedFileName.startsWith("-") && stripedFileName.contains(".")) { classifier = stripedFileName.substring(1, stripedFileName.indexOf('.')); } extension = stripedFileName.substring(stripedFileName.indexOf('.') + 1); MavenCoord coord = new MavenCoord(); coord.groupId = groupId; coord.artifactId = artifactId; coord.type = extension; coord.classifier = classifier; coord.version = version; return coord; } return null; } /** * Converts the path of the request to {@link MavenCoord}. * * @param path The request path, following the format: {@code <groupId>/<artifactId>/<version>/<artifactId>-<version>-[<classifier>].extension} * @return A {@link MavenCoord} * @throws InvalidMavenArtifactRequest */ protected MavenCoord convertMetadataPathToCoord(String path) throws InvalidMavenArtifactRequest { if (path == null) { throw new InvalidMavenArtifactRequest("Cannot match request path to maven url, request path is empty."); } Matcher pathMatcher = ARTIFACT_METADATA_URL_REGEX.matcher(path); if (pathMatcher.matches()) { MavenCoord coord = new MavenCoord(); coord.groupId = pathMatcher.group(1).replaceAll("/", "."); coord.artifactId = pathMatcher.group(2); coord.version = pathMatcher.group(3); String type = pathMatcher.group(8); coord.type = type == null ? "maven-metadata.xml" : "maven-metadata.xml." + type; return coord; } return null; } static class MavenCoord { String groupId; String artifactId; String type; String classifier; String version; @Override public String toString() { StringBuilder sb = new StringBuilder(); sb.append(groupId).append(":").append(artifactId).append(":"); sb.append(type).append(":"); if (classifier != null && !classifier.isEmpty()) { sb.append(classifier).append(":"); } sb.append(version); return sb.toString(); } } }